import java.util.*;
import java.io.*;
import java.nio.charset.Charset;
import java.sql.*;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

import org.json.JSONArray;
import org.json.JSONObject;

/**
 * Менеджер справочников ФНСИ. Управляет их загрузкой, а также сохранением в
 * бинарный вид для быстрой загрузки при холодном старте.
 * Часть данных о справочниках читает из SQLite3 базы, которую ведет
 * консольная утилита fnsi-downloader. В базу ничего не пишет.
 * Справочник отдает как основному, так и по дополнительным OID справочника (по
 * данным паспорта).
 * Также можно запросить данные справочника без указания версии, тогда будут
 * возвращены данные последней версии.
 * 
 * Менеджер обеспечивается Thread-safe использование справочников.
 */
final public class RefManager {
    private final HashMap<String, Ref> fnsi = new HashMap<String, Ref>(600);
    private final HashMap<String, String> actual_ver = new HashMap<String, String>(); // ключ - oid, значение - версия
    private final HashMap<String, HashMap<String, String>> fnsi_emd = new HashMap<String, HashMap<String, String>>(
            10000); // ключ - тип РЭМД для СЭМД, значение - хеш (ключ - oid, значение - версия)

    private final ReadWriteLock readWriteLock_fnsi = new ReentrantReadWriteLock();
    private final Lock writeLock_fnsi = readWriteLock_fnsi.writeLock();
    private final Lock readLock_fnsi = readWriteLock_fnsi.readLock();

    private final ReadWriteLock readWriteLock_fnsi_emd = new ReentrantReadWriteLock();
    private final Lock writeLock_fnsi_emd = readWriteLock_fnsi_emd.writeLock();
    private final Lock readLock_fnsi_emd = readWriteLock_fnsi_emd.readLock();

    private final ReadWriteLock readWriteLock_actual_ver = new ReentrantReadWriteLock();
    private final Lock writeLock_actual_ver = readWriteLock_actual_ver.writeLock();
    private final Lock readLock_actual_ver = readWriteLock_actual_ver.readLock();

    private final String REF_DIR;
    private final String SQLITE3_CONNECTION;

    static private final Properties config = new Properties();

    RefManager(String REF_DIR, String SQLITE3_CONNECTION) {
        this.REF_DIR = REF_DIR;
        this.SQLITE3_CONNECTION = SQLITE3_CONNECTION;
        config.setProperty("open_mode", "1"); // 1 == readonly
    }

    /*
     * userKey=xxx&identifier=1.2.643.5.1.13.13.11.1522&version=1.2&filters=code|1
     * userKey=xxx&identifier=1.2.643.5.1.13.13.11.1522&remdtype=114&filters=code|1
     * userKey=xxx&identifier=1.2.643.5.1.13.13.11.1522&size=20&page=2
     * userKey=xxx&identifier=1.2.643.5.1.13.13.11.1522&filters=Name|Протокол|LIKE&
     * size=20&page=2
     * userKey=xxx&identifier=1.2.643.5.1.13.13.11.1522&filters=name|наименование|
     * LIKE2&size=20&page=2
     * 
     * где xxx - идентификатор запроса. Формат
     * не контролируется. Идентификатор должен быть уникальным для пагинированных
     * запросов в рамках получения страниц. То есть запрос на получение N-ой
     * страницы должен иметь тот же идентификатор, что и на N-1. Для пагинированных
     * запросов в параметре page значение номера страницы не важно, тут используется
     * логика работы курсоров в СУБД. Вызов интерпретируется как next у курсора.
     * 
     * Все запросы data являются пагинированными. Если параметр size не указан, то
     * берется значение по умолчанию - 20.
     * 
     * В качестве имени поля в команде data, параметр filters без указания
     * модификатора LIKE возможно только ключа справочника. Точнее в этом случае
     * значение имени поля игнорируется. Выполняется поиск по ключу (key). Тоже
     * самое и для случая с указанием LIKE, то тогда поиск всегда выполняется по
     * value.
     */
    public JSONObject request(final String url_get) throws IOException, SQLException {
        String userKey = null;
        String oid = null;
        String ver = null;
        Integer size = 20;
        String pattern = null;
        String remdtype = null;

        final String[] params = url_get.split("&");
        if (params.length < 2)
            return errorAnswer("Ошибочный запрос");

        try {
            for (int i = 0; i < params.length; i++) {
                String p = params[i];
                if (p.startsWith("userKey="))
                    userKey = p.substring(8);
                else if (p.startsWith("identifier="))
                    oid = p.substring(11);
                else if (p.startsWith("version="))
                    ver = p.substring(8);
                else if (p.startsWith("size="))
                    size = Integer.parseInt(p.substring(5));
                else if (p.startsWith("filters="))
                    pattern = p.substring(8);
                else if (p.startsWith("remdtype="))
                    remdtype = p.substring(9);
            }
            if (oid == null)
                return errorAnswer("Ошибочный запрос - отсутствует identifier");
            if (userKey == null)
                return errorAnswer("Ошибочный запрос - отсутствует userKey");

            if (remdtype != null) {
                ver = getRefVersionForSemd(remdtype, oid);
                if (ver == null) {
                    return errorAnswer(
                            "Не удалось определить версию справочника " + oid + " для типа РЭМД " + remdtype);
                }
            } else if (ver == null) {
                ver = getActualRefVersion(oid);
                if (ver == null) {
                    return errorAnswer(
                            "Ошибочный запрос - не удалось определить актуальную версию справочника " + oid);
                }
            }

            final Ref ref = getRef(oid, ver);
            if (ref == null) {
                return errorAnswer("Не удалось загрузить справочник " + oid + " " + ver);
            }
            StringBuilder requestId = new StringBuilder(userKey);
            requestId.append('&');
            requestId.append(oid);
            requestId.append('&');
            requestId.append(ver);

            if (pattern == null) {
                return ref.data(requestId.toString(), size);
            }
            requestId.append('&');
            requestId.append(pattern);

            String[] p = pattern.split("\\|");
            if (p.length == 2) {
                return ref.filterKey(p[1]);
            }
            if (p.length == 3) {
                if (p[2].equals("LIKE"))
                    return ref.filterLike(requestId.toString(), p[1], size);
                if (p[2].equals("LIKE2"))
                    return ref.filterLike2(requestId.toString(), p[1], size);
            }
            return errorAnswer("Ошибочный запрос");
        } catch (NumberFormatException e) {
            return errorAnswer("Ошибочный запрос");
        }
    }

    /**
     * Получает справочник ФНСИ по его oid и номеру версии. Если справочника нет в
     * in-momory DB, то он загружается с диска (бинарный файл) из
     * REF_DIR. Предполагается, что значение переменной REF_DIR заканчивается
     * на слеш.
     * 
     * @param oid   - OID справочника. Можно указывать как основной OID, так и
     *              дополнительные.
     * @param ver   - Версия справочника. В качестве версии необходимо использовать
     *              точный номер.
     * @return
     * @throws IOException
     * @throws SQLException
     */
    public Ref getRef(final String oid, final String ver) throws IOException, SQLException {
        Ref ref;
        boolean needAddOther = false;

        readLock_fnsi.lock();
        try {
            ref = fnsi.get(oid + "-" + ver);
        } finally {
            readLock_fnsi.unlock();
        }

        if (ref == null) {
            ref = new Ref(oid, ver);
            writeLock_fnsi.lock();
            try {
                fnsi.put(oid + "-" + ver, ref);
            } finally {
                writeLock_fnsi.unlock();
            }
        }

        synchronized (ref) {
            needAddOther = loadRefFromDB(ref);
            if (ref.isEmpty()) {
                loadBinaryRef(ref);
            }
        }

        if (needAddOther && ref.codeSystemOther != null) {
            writeLock_fnsi.lock();
            try {
                for (String r : ref.codeSystemOther) {
                    fnsi.put(r + "-" + ver, ref);
                }
            } finally {
                writeLock_fnsi.unlock();
            }
        }

        return ref;
    }

    /**
     * В FNSIlist.json были загружены новые настройки справочника.
     * Удаляем все версии справочника. С диска они также должны быть удалены ранее
     * 
     * @param oid - ОИД справочника
     */
    public boolean clearRefData(final String oid) {
        boolean mod = false;
        writeLock_fnsi.lock();
        try {
            LinkedList<String> toRemove = new LinkedList<String>();
            for (String ref_name : fnsi.keySet()) {
                String[] r = ref_name.split("-");
                if (r[0].equals(oid)) {
                    toRemove.add(ref_name);
                    mod = true;
                }
            }
            for (String i : toRemove) {
                fnsi.remove(i);
            }
        } finally {
            writeLock_fnsi.unlock();
        }
        return mod;
    }

    /**
     * В FNSIlist.json были загружены новые настройки валидации по справочнику.
     * Сбрасываем флаг загруженности сведений о настройках справочника
     * 
     * @param oid - ОИД справочника
     */
    public boolean clearRefSetting(final String oid) {
        boolean mod = false;
        readLock_fnsi.lock();
        try {
            for (String ref_name : fnsi.keySet()) {
                String[] r = ref_name.split("-");
                if (r[0].equals(oid)) {
                    Ref ref = fnsi.get(ref_name);
                    ref.passportLoaded = false;
                    mod = true;
                }
            }
        } finally {
            readLock_fnsi.unlock();
        }
        return mod;
    }

    /**
     * Если в fnsi-downloader обновился справочник 1.2.643.5.1.13.13.99.2.805, то сбрасываем
     * его копию
     */
    public void clearFNSIEMD() {
        writeLock_fnsi_emd.lock();
        try {
            fnsi_emd.clear();
        } finally {
            writeLock_fnsi_emd.unlock();
        }
    }

    /**
     * Получение номера последней версии справочника ФНСИ. Данные берутся из базы
     * SQLite3, куда записывает fnsi-downloader.
     * Если сведений еще не загружены или сброшены, то загружаем.
     * 
     * @param oid
     * @return
     * @throws SQLException
     */
    public String getActualRefVersion(final String oid) throws SQLException {
        writeLock_actual_ver.lock();
        try {
            if (actual_ver.size() == 0) {
                loadActualVerFNSI();
            }
        } finally {
            writeLock_actual_ver.unlock();
        }

        readLock_actual_ver.lock();
        try {
            return actual_ver.get(oid);
        } finally {
            readLock_actual_ver.unlock();
        }
    }

    /**
     * Получение номера версии справочника, которой необходимо использовать при
     * формировании СЭМД. Эти сведения получаются из справочника
     * 1.2.643.5.1.13.13.99.2.805. Сведения вычитываются из SQLite3
     * базы, куда они загружаются в fnsi-downloader.
     * 
     * @param remd_type - тип СЭМД по справочнику 1.2.643.5.1.13.13.11.1520
     * @param oid       - OID справочника
     * @return
     * @throws SQLException
     */
    public String getRefVersionForSemd(final String remd_type, final String oid) throws SQLException {
        writeLock_fnsi_emd.lock();
        try {
            if (fnsi_emd.size() == 0) {
                loadFNSI_EMD();
            }
        } finally {
            writeLock_fnsi_emd.unlock();
        }

        readLock_fnsi_emd.lock();
        try {
            HashMap<String, String> r = fnsi_emd.get(remd_type);
            return (r != null) ? r.get(oid) : null;
        } finally {
            readLock_fnsi_emd.unlock();
        }
    }

    private boolean loadRefFromDB(final Ref ref) throws SQLException {
        if (ref.passportLoaded)
            return false;

        Connection conn = DriverManager.getConnection(SQLITE3_CONNECTION, config);
        PreparedStatement st = conn.prepareStatement(String.join("",
                "select codeSystemName, rowsCount, codeSystemOther,",
                "ifnull(ref_setting.keyField, fnsi_ref.keyField), ifnull(ref_setting.valueField, ",
                "fnsi_ref.valueField), rekv, ifnull(load, 1), ifnull(validateRefName, 1), ifnull(validateValue,1) ",
                "from fnsi_ref left join ref_setting on fnsi_ref.codeSystem=ref_setting.codeSystem ",
                "where fnsi_ref.codesystem=? and fnsi_ref.version=?"));
        st.setString(1, ref.codeSystem);
        st.setString(2, ref.version);
        ResultSet rs = st.executeQuery();
        if (rs.next()) {
            ref.codeSystemName = rs.getString(1);
            ref.rowsCount = rs.getInt(2);
            String other = rs.getString(3);
            ref.codeSystemOther = (other != null) ? other.split(",") : null;
            ref.keyField = rs.getString(4);
            ref.valueField = rs.getString(5);
            String names = rs.getString(6);
            ref.fieldNames = (names != null) ? names.split(",") : null;
            ref.load = rs.getBoolean(7);
            ref.validateRefName = rs.getBoolean(8);
            ref.validateValue = rs.getBoolean(9);
        }
        rs.close();
        st.close();
        conn.close();
        ref.passportLoaded = true;
        return true;
    }

    private void loadActualVerFNSI() throws SQLException {
        Connection conn = DriverManager.getConnection(SQLITE3_CONNECTION, config);
        Statement st = conn.createStatement();
        ResultSet rs = st.executeQuery("select codeSystem, version from fnsi_ref where actual='*'");
        while (rs.next()) {
            String codeSystem = rs.getString(1);
            String ver = rs.getString(2);
            actual_ver.put(codeSystem, ver);
        }
        rs.close();
        st.close();
        conn.close();
    }

    private void loadFNSI_EMD() throws SQLException {
        if (fnsi_emd.size() != 0)
            return;

        Connection conn = DriverManager.getConnection(SQLITE3_CONNECTION, config);
        Statement st = conn.createStatement();
        ResultSet rs = st.executeQuery(String.join("",
                "select ref, fnsi_ref.version, fnsi_emd.id ",
                "from fnsi_semd_refs ",
                "inner join fnsi_emd on fnsi_emd.git_link=fnsi_semd_refs.oid ",
                "inner join fnsi_ref on fnsi_semd_refs.ref=fnsi_ref.codeSystem and ",
                " (fnsi_semd_refs.version=fnsi_ref.actual or fnsi_semd_refs.version=fnsi_ref.version)"));
        while (rs.next()) {
            String oid = rs.getString(1);
            String ver = rs.getString(2);
            String remdtype = String.valueOf(rs.getInt(3));
            HashMap<String, String> r = fnsi_emd.get(remdtype);
            if (r == null) {
                r = new HashMap<String, String>(20);
                fnsi_emd.put(remdtype, r);
            }
            r.put(oid, ver);
        }
        rs.close();
        st.close();
        conn.close();
    }

    private boolean loadBinaryRef(final Ref ref) throws IOException {
        try {
            ref.loadBinary(REF_DIR + ref.codeSystem + "-" + ref.version + ".avl");
            return true;
        } catch (FileNotFoundException ex) {
            return false;
        }
    }

    // для демонстрации возможности сохранения в JSON
    void loadRefJSON(final Ref ref) throws IOException {
        BufferedReader reader = new BufferedReader(
                new FileReader(REF_DIR + ref.codeSystem + "-" + ref.version + ".json", Charset.forName("Cp1251")));
        ref.loadJSON(reader);
        reader.close();
    }

    // для демонстрации возможности сохранения в JSON
    void saveRefJSON(final Ref ref) throws IOException {
        if (ref != null) {
            Writer wdata = new BufferedWriter(new OutputStreamWriter(
                    new FileOutputStream(REF_DIR + ref.codeSystem + "-" + ref.version + ".json"), "Cp1251"));
            ref.saveJSON(wdata);
            wdata.close();
        }
    }

    /**
     * Формирование ответа с ошибкой в формате JSON
     */
    private static JSONObject errorAnswer(final String message) {
        JSONObject ret = new JSONObject();
        ret.put("result", "ERROR");
        ret.put("resultText", message);
        ret.put("resultCode", (Object) null);
        ret.put("total", 0);
        ret.put("hasNext", false);
        JSONArray a = new JSONArray(0);
        ret.put("list", a);
        return ret;
    }
}
